웹훅 서명을 검증해야 하는 이유
웹훅 서명을 검증해야 하는 이유
공급자가 공유한 비밀키로 원본 요청 바이트의 HMAC을 계산하고 전달된 서명과 상수 시간 비교를 수행한다.
목차
- #문제가 되는 상황
- #HTTPS만으로 발신자를 확인할 수 없다
- #HMAC 서명이 검증하는 것
- #반드시 원본 바이트를 사용한다
- #timestamp를 포함해 replay를 제한한다
- #상수 시간 비교와 형식 검증
- #비밀 키 교체하기
- #서명 검증 뒤에도 중복과 순서를 처리한다
- #빠르게 응답하고 비동기로 처리한다
- #실전 점검 목록
- #결론
- #관련 노트
문제가 되는 상황
결제 공급자가 payment.completed 웹훅을 보낸다고 해도 endpoint 자체는 인터넷에 공개되어 있다. 공격자는 같은 JSON 모양을 만들어 직접 POST할 수 있다. 본문에 주문 ID와 status: paid가 있다는 이유만으로 주문을 완료 처리하면 결제하지 않은 주문도 성공 상태로 바뀔 수 있다.
HTTPS는 전송 중 도청과 변조를 줄이고 서버의 신원을 client에 증명하지만, endpoint에 들어온 POST가 특정 웹훅 공급자가 만든 요청이라는 사실을 자동으로 증명하지는 않는다. 발신자와 본문 무결성을 확인하려면 공급자와 합의한 서명 검증이 필요하다.
결제 이벤트, 헤더 이름, 서명 형식은 원리를 설명하기 위해 만든 가상 계약이다. 실제 공급자의 문서와 형식을 그대로 사용하지 않았다.
HTTPS만으로 발신자를 확인할 수 없다
웹훅 endpoint가 다음 주소라고 하자.
POST https://api.example.test/webhooks/payments
주소를 아는 누구나 요청을 보낼 수 있다. IP allowlist를 추가할 수 있지만 공급자의 주소 범위가 바뀌거나 proxy를 거칠 수 있고, 단독으로 본문 무결성을 제공하지 않는다. 공급자가 제공하는 mTLS 같은 강한 방식이 없다면 HMAC 서명이 일반적인 출발점이다.
서명 검증은 인증과 별개인 업무 검증을 대신하지 않는다. 공급자가 실제로 보낸 이벤트라도 금액, 통화, 주문 소유자, 허용 상태 전이가 우리 DB와 일치하는지 확인해야 한다.
HMAC 서명이 검증하는 것
공급자와 수신 서버가 공유 secret을 가지고 있다고 하자. 공급자는 timestamp와 원본 본문으로 signing payload를 만들고 HMAC을 계산한다.
signed_payload = timestamp + "." + raw_body
signature = HMAC-SHA256(secret, signed_payload)
요청은 다음처럼 온다.
POST /webhooks/payments HTTP/1.1
Content-Type: application/json
X-Webhook-Timestamp: 1788282000
X-Webhook-Signature: v1=5f3c...example...
{"id":"evt-901","type":"payment.completed","data":{"orderId":"order-42"}}
수신자는 같은 secret과 정확히 같은 입력으로 서명을 계산한다. 값이 일치하면 secret을 아는 주체가 해당 바이트와 timestamp 조합에 대한 서명을 만들었다고 판단할 수 있다.
function computeSignature(
secret: Buffer,
timestamp: string,
rawBody: Buffer,
): Buffer {
return createHmac("sha256", secret)
.update(timestamp, "ascii")
.update(".", "ascii")
.update(rawBody)
.digest();
}
반드시 원본 바이트를 사용한다
프레임워크의 JSON middleware가 먼저 본문을 객체로 바꾸면 원본 표현이 사라질 수 있다. 다음 두 JSON은 의미상 같지만 바이트가 다르다.
{"amount":32000,"currency":"KRW"}
{
"currency": "KRW",
"amount": 32000
}
키 순서, 공백, Unicode 표현, 줄바꿈이 달라지면 HMAC 결과도 달라진다. 파싱한 객체를 JSON.stringify()로 다시 만든 값은 공급자가 서명한 원본과 같다는 보장이 없다.
app.post(
"/webhooks/payments",
rawBodyMiddleware({ type: "application/json", limit: "1mb" }),
handlePaymentWebhook,
);
서명을 검증한 후에만 JSON을 파싱한다.
async function handlePaymentWebhook(request, response) {
verifyWebhookSignature({
rawBody: request.body,
signatureHeader: request.get("x-webhook-signature"),
timestampHeader: request.get("x-webhook-timestamp"),
});
const event = parseWebhookEvent(JSON.parse(request.body.toString("utf8")));
await enqueueVerifiedEvent(event);
return response.status(202).end();
}
timestamp를 포함해 replay를 제한한다
유효한 서명 요청을 누군가 캡처해 그대로 여러 번 보내면 HMAC은 계속 유효하다. 서명 입력에 timestamp를 포함하고 현재 시각과 허용 범위를 비교해 오래된 요청을 거부한다.
function assertRecentTimestamp(raw: string, nowSeconds: number) {
if (!/^\d{10}$/.test(raw)) {
throw new Error("INVALID_WEBHOOK_TIMESTAMP");
}
const timestamp = Number(raw);
const toleranceSeconds = 5 * 60;
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) {
throw new Error("WEBHOOK_TIMESTAMP_OUT_OF_RANGE");
}
}
허용 범위는 공급자의 retry 특성과 서버 시각 오차를 고려해 정한다. NTP 등으로 서버 시간을 동기화하고, timestamp를 검증만 한 뒤 HMAC 입력에는 포함하지 않는 실수를 피한다.
짧은 시간 안의 replay는 timestamp만으로 구분할 수 없다. event ID를 중복 처리 방지 키로 사용한다.
상수 시간 비교와 형식 검증
일반 문자열 비교는 첫 불일치 위치에 따라 실행 시간이 달라질 수 있다. 암호학적 값은 런타임이 제공하는 constant-time 비교 함수를 사용한다. 비교 전에 encoding과 길이를 엄격히 검증해야 한다.
function verifyHexSignature(expected: Buffer, providedHex: string) {
if (!/^[0-9a-f]{64}$/i.test(providedHex)) {
throw new Error("INVALID_SIGNATURE_FORMAT");
}
const provided = Buffer.from(providedHex, "hex");
if (provided.length !== expected.length) {
throw new Error("INVALID_SIGNATURE_LENGTH");
}
if (!timingSafeEqual(expected, provided)) {
throw new Error("INVALID_WEBHOOK_SIGNATURE");
}
}
여러 버전의 서명이 한 헤더에 들어오는 공급자도 있다. 단순히 문자열 전체를 비교하지 말고 공식 parser 규칙에 따라 version별 값을 추출한다.
비밀 키 교체하기
웹훅 secret도 유출과 정기 교체를 고려해야 한다. 교체 시 공급자가 새 secret으로 바꾸는 순간과 수신 서버 배포 시점이 정확히 맞지 않을 수 있다. 제한된 기간 동안 현재 키와 다음 키를 모두 검증 후보로 둘 수 있다.
function verifyWithActiveSecrets(input, signatures, secrets) {
let matched = false;
for (const secret of secrets) {
const expected = computeSignature(secret.value, input.timestamp, input.rawBody);
matched = compareAgainstAnySignature(expected, signatures) || matched;
}
if (!matched) throw new Error("INVALID_WEBHOOK_SIGNATURE");
}
secret에는 key ID, 활성 시작·종료 시각을 두고 Secret Manager에서 읽는다. 코드와 repository, 환경 dump, 로그에 원문을 넣지 않는다. 구 키 허용 기간이 끝나면 제거하고 실제로 어느 키로 검증되었는지 안전한 key ID만 지표에 남긴다.
서명 검증 뒤에도 중복과 순서를 처리한다
웹훅 공급자는 응답 timeout이나 5xx가 발생하면 같은 event를 재전송한다. 서명이 모두 유효해도 업무 처리를 두 번 실행하면 안 된다.
CREATE TABLE webhook_events (
provider VARCHAR(40) NOT NULL,
event_id VARCHAR(120) NOT NULL,
event_type VARCHAR(80) NOT NULL,
status VARCHAR(20) NOT NULL,
received_at DATETIME NOT NULL,
processed_at DATETIME NULL,
PRIMARY KEY (provider, event_id)
);
unique constraint로 최초 event만 등록하고 중복은 이미 처리한 결과로 인정한다. event ID의 범위를 공급자 또는 account와 함께 묶는다.
도착 순서도 보장된다고 가정하지 않는다. payment.refunded가 네트워크 지연으로 payment.completed보다 먼저 도착할 수 있다. 이벤트 순서만으로 상태를 덮어쓰기보다 공급자의 최신 리소스를 조회하거나 version·event time과 허용 상태 전이를 검증한다.
const allowedTransitions = {
pending: new Set(["paid", "cancelled"]),
paid: new Set(["refunded"]),
refunded: new Set(),
};
빠르게 응답하고 비동기로 처리한다
서명 검증 뒤 외부 API 호출과 무거운 작업을 모두 끝낼 때까지 응답을 미루면 공급자가 timeout으로 재시도한다. 요청 경로에서는 크기 제한, 서명·timestamp·스키마 검증, durable queue 또는 event table 기록까지만 하고 빠르게 성공 응답을 보낸다.
sequenceDiagram
participant P as Provider
participant W as Webhook Endpoint
participant Q as Durable Queue
participant D as Worker
P->>W: signed event
W->>W: raw body 서명·timestamp 검증
W->>Q: event ID와 payload 저장
W-->>P: 202 Accepted
Q-->>D: event 전달
D->>D: 업무 멱등 처리queue에 넣기 전에 성공을 응답하면 프로세스 종료 시 event를 잃을 수 있다. 반대로 처리까지 기다리면 timeout 가능성이 커진다. 성공 응답의 기준을 “durable하게 인수했다”로 정한다.
실패 응답 정책도 공급자 문서를 따른다. 영구적인 잘못된 서명에 500을 주면 공격 요청이 재시도될 수 있고, 일시적인 저장소 장애에 200을 주면 정상 event를 잃는다.
실전 점검 목록
- 프레임워크가 JSON 파싱 전에 raw body를 보존하는가?
- 서명 입력에 timestamp와 정확한 원본 바이트가 들어가는가?
- 서명 형식·길이를 검증한 뒤 constant-time으로 비교하는가?
- secret이 Secret Manager에 있고 교체 기간이 정의되어 있는가?
- event ID unique constraint로 중복 처리를 막는가?
- 순서가 뒤바뀐 이벤트도 상태 전이 규칙으로 처리하는가?
- durable 저장 후 빠르게 응답하는가?
- secret, signature, 전체 개인정보 payload를 로그에 남기지 않는가?
공급자가 공유한 비밀키로 원본 요청 바이트의 HMAC을 계산하고 전달된 서명과 상수 시간 비교를 수행한다.
결론
웹훅은 원본 요청 바이트와 timestamp에 대한 HMAC을 신뢰한 secret으로 다시 계산하고, 형식·길이 확인 후 constant-time으로 비교해야 한다. 검증 성공은 발신자와 본문 무결성의 근거일 뿐 업무 처리의 중복·순서·상태 전이까지 보장하지 않는다. event ID를 durable하게 저장하고 빠르게 응답하며 secret 교체와 replay 제한까지 수신 생명주기에 포함해야 한다.